Skip to content

Conversation

unknownunknown1
Copy link
Contributor

@unknownunknown1 unknownunknown1 commented Oct 6, 2025

PR-Codex overview

This PR focuses on enhancing the DisputeKitClassicBase contract's handling of disputes by refining the active status checks and modifying the structure of disputes and rounds. It also improves error handling and updates related events and function signatures.

Detailed summary

  • Added a new struct Active to track dispute and round activity.
  • Replaced the jumped boolean with Active struct in coreDisputeIDToActive.
  • Updated modifier from notJumped to isActive to check dispute status.
  • Adjusted functions to use the new active status checks.
  • Simplified event emissions related to withdrawals.
  • Updated tests to reflect changes in dispute activity checks and error handling.

✨ Ask PR-Codex anything about this PR by commenting with /codex {your question}

Summary by CodeRabbit

  • Bug Fixes

    • Robust active-tracking for disputes to prevent duplicate local disputes, ensure correct round indexing, and consolidate withdrawal across rounds.
    • Reworked checks so actions revert for unknown or inactive disputes; updated withdrawal/event semantics for consistent accounting.
  • Tests

    • Added end-to-end test covering recurring multi-round dispute-kit and court jumps, funding, voting, appeals, and withdrawals.

Copy link

netlify bot commented Oct 6, 2025

Deploy Preview for kleros-v2-testnet-devtools failed. Why did it fail? →

Name Link
🔨 Latest commit a0a3a67
🔍 Latest deploy log https://app.netlify.com/projects/kleros-v2-testnet-devtools/deploys/68ee60a017ee9b0008472b38

Copy link
Contributor

coderabbitai bot commented Oct 6, 2025

Walkthrough

Replaces per-dispute boolean active flag with an Active struct (dispute, currentRound), reworks createDispute to reuse active local disputes or create new ones, updates modifiers to isActive, changes withdrawal/event signatures, and adjusts tests to the new active/currentRound and withdrawal semantics.

Changes

Cohort / File(s) Summary
Dispute kit core logic
contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol
Replace mapping(uint256 => bool) coreDisputeIDToActive with mapping(uint256 => Active) coreDisputeIDToActive; add struct Active { bool dispute; bool currentRound; }; change createDispute to reuse active local disputes or create new ones; set/clear Active.currentRound; replace notJumped modifier with isActive; rename errors (DisputeJumpedToAnotherDisputeKit, add DisputeUnknownInThisDisputeKit); change Withdrawal event signature and update withdraw logic to iterate rounds and reset per-round contributions.
Shutter dispute-kits (modifiers removed)
contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol, contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol
Removed notJumped(_coreDisputeID) modifier from castCommitShutter external functions; internal _castCommit now enforces dispute activity. Comments adjusted.
Dispute kit interface docs
contracts/src/arbitration/interfaces/IDisputeKit.sol
Add doc note to createDispute that KlerosCore.Round must be created before calling; no signature changes.
Tests — Appeals / Voting / Disputes / Execution
contracts/test/foundry/KlerosCore_Appeals.t.sol, contracts/test/foundry/KlerosCore_Disputes.t.sol, contracts/test/foundry/KlerosCore_Execution.t.sol, contracts/test/foundry/KlerosCore_Voting.t.sol
Add test_appeal_recurringDK() end-to-end recurring-DK jump test; update tests to use new coreDisputeIDToActive tuple return (extract disputeActive and currentRound), adapt disputes() reduced return shape, expect renamed errors, and update withdrawal call signatures and emitted event expectations.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Core as KlerosCore
  participant DK as DisputeKitClassicBase
  participant Store as Storage (mappings)

  Core->>DK: createDispute(coreDisputeID,...)
  DK->>Store: read coreDisputeIDToActive[coreDisputeID]
  alt Active.dispute == true
    DK->>Store: localID = coreDisputeIDToLocal[coreDisputeID]
    DK->>DK: reuse local dispute, set Active.currentRound = true
    DK->>Store: coreRoundIDToLocal[newCoreRoundID] = dispute.rounds.length (store-length)
    DK-->>Core: return localID
  else Active.dispute == false
    DK->>DK: create new local dispute & round
    DK->>Store: coreDisputeIDToLocal[coreDisputeID] = newLocalID
    DK->>Store: coreDisputeIDToActive[coreDisputeID] = Active(true,true)
    DK->>Store: coreRoundIDToLocal[newCoreRoundID] = dispute.rounds.length
    DK-->>Core: return newLocalID
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

Type: Bug :bug:, Package: Contracts, Compatibility: ABI change 🗯, Storage layout incompatibility ⚠️, Audit: Contract Reviews 👀

Suggested reviewers

  • unknownunknown1

Poem

I twitch my nose at rounds anew,
Reuse the burrow, hop right through.
Jumps untangled, contributions find,
Rewards returned, no thread left behind.
🐇✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly captures the core fix—enabling recurring dispute kit flows—using the conventional commit prefix and specifying DK, which aligns clearly with the changes in DisputeKitClassicBase to support disputes jumping between kits.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/recurring-dk-test

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

netlify bot commented Oct 6, 2025

Deploy Preview for kleros-v2-testnet ready!

Name Link
🔨 Latest commit a0a3a67
🔍 Latest deploy log https://app.netlify.com/projects/kleros-v2-testnet/deploys/68ee60a05b6e9a0007d6277a
😎 Deploy Preview https://deploy-preview-2159--kleros-v2-testnet.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link

netlify bot commented Oct 6, 2025

Deploy Preview for kleros-v2-neo ready!

Name Link
🔨 Latest commit a0a3a67
🔍 Latest deploy log https://app.netlify.com/projects/kleros-v2-neo/deploys/68ee60a0fa93460008d14023
😎 Deploy Preview https://deploy-preview-2159--kleros-v2-neo.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

coderabbitai[bot]
coderabbitai bot previously approved these changes Oct 6, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (4)
contracts/test/foundry/KlerosCore_Appeals.t.sol (4)

609-619: Pin event emitters to avoid false positives

Tie expected logs to their emitters to ensure they come from the intended contracts.

Apply this diff:

-        vm.expectEmit(true, true, true, true);
+        vm.expectEmit(address(core));
         emit KlerosCore.CourtJump(disputeID, 1, courtID3, courtID2);
-        vm.expectEmit(true, true, true, true);
+        vm.expectEmit(address(core));
         emit KlerosCore.DisputeKitJump(disputeID, 1, dkID3, dkID2);
-        vm.expectEmit(true, true, true, true);
+        vm.expectEmit(address(disputeKit2));
         emit DisputeKitClassicBase.DisputeCreation(disputeID, 2, newExtraData);
-        vm.expectEmit(true, true, true, true);
+        vm.expectEmit(address(core));
         emit KlerosCore.AppealDecision(disputeID, arbitrable);
-        vm.expectEmit(true, true, true, true);
+        vm.expectEmit(address(core));
         emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence);

685-691: Also pin emitters on the second jump (and consider asserting NewPeriod)

Use specific emitters here as well; optionally assert NewPeriod(evidence) like in the first jump.

-        vm.expectEmit(true, true, true, true);
+        vm.expectEmit(address(core));
         emit KlerosCore.CourtJump(disputeID, 2, courtID2, GENERAL_COURT);
-        vm.expectEmit(true, true, true, true);
+        vm.expectEmit(address(core));
         emit KlerosCore.DisputeKitJump(disputeID, 2, dkID2, dkID3);
+        vm.expectEmit(address(core));
+        emit KlerosCore.NewPeriod(disputeID, KlerosCore.Period.evidence);

605-606: Avoid magic funding amounts; compute from appealCost and multipliers

Hard-coded values (0.63, 0.42, 1.35, 0.9 ether) will drift if fees/multipliers change. Derive from core.appealCost(disputeID).

Example:

-        disputeKit3.fundAppeal{value: 0.63 ether}(disputeID, 1);
+        uint256 ac1 = core.appealCost(disputeID);
+        uint256 lose1 = ac1 + (ac1 * 20000) / Constants.MULTIPLIER_DIVISOR;
+        disputeKit3.fundAppeal{value: lose1}(disputeID, 1);
...
-        disputeKit3.fundAppeal{value: 0.42 ether}(disputeID, 2);
+        uint256 win1 = ac1 + (ac1 * 10000) / Constants.MULTIPLIER_DIVISOR;
+        disputeKit3.fundAppeal{value: win1}(disputeID, 2);
...
-        disputeKit2.fundAppeal{value: 1.35 ether}(disputeID, 1);
+        uint256 ac2 = core.appealCost(disputeID);
+        uint256 lose2 = ac2 + (ac2 * 20000) / Constants.MULTIPLIER_DIVISOR;
+        disputeKit2.fundAppeal{value: lose2}(disputeID, 1);
...
-        disputeKit2.fundAppeal{value: 0.9 ether}(disputeID, 2);
+        uint256 win2 = ac2 + (ac2 * 10000) / Constants.MULTIPLIER_DIVISOR;
+        disputeKit2.fundAppeal{value: win2}(disputeID, 2);

Note: If multipliers are accessible via getters, prefer them over literals.

Also applies to: 620-621, 683-684, 690-691


758-766: Optional: assert total rounds after the second jump

Quick sanity on core-side tracking.

         core.executeRuling(disputeID); // winning choice is 2
+        assertEq(core.getNumberOfRounds(disputeID), 3, "Should have 3 rounds after two appeals");
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 514a047 and 3d197d6.

📒 Files selected for processing (1)
  • contracts/test/foundry/KlerosCore_Appeals.t.sol (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Redirect rules - kleros-v2-testnet
  • GitHub Check: Redirect rules - kleros-v2-testnet-devtools
  • GitHub Check: Redirect rules - kleros-v2-testnet
  • GitHub Check: Header rules - kleros-v2-testnet-devtools
  • GitHub Check: Header rules - kleros-v2-testnet
  • GitHub Check: Redirect rules - kleros-v2-neo
  • GitHub Check: Header rules - kleros-v2-testnet
  • GitHub Check: Pages changed - kleros-v2-testnet-devtools
  • GitHub Check: Header rules - kleros-v2-neo
  • GitHub Check: Pages changed - kleros-v2-testnet
  • GitHub Check: Pages changed - kleros-v2-testnet
  • GitHub Check: Pages changed - kleros-v2-neo
  • GitHub Check: Analyze (javascript)
  • GitHub Check: hardhat-tests
🔇 Additional comments (1)
contracts/test/foundry/KlerosCore_Appeals.t.sol (1)

470-793: Thorough recurring DK/court jump scenario — looks solid

Covers re-entry semantics, round mapping, jump gating, events, and withdrawals end-to-end. No blockers.

coderabbitai[bot]
coderabbitai bot previously approved these changes Oct 8, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol (1)

141-144: notJumped can revert with storage OOB for unknown disputes

If called with a _coreDisputeID this DK never created, coreDisputeIDToLocal[_coreDisputeID] is 0 and disputes[0] may be OOB (length==0). Prefer checking activity first to provide a deterministic custom error instead of a panic.

-    modifier notJumped(uint256 _coreDisputeID) {
-        if (disputes[coreDisputeIDToLocal[_coreDisputeID]].jumped) revert DisputeJumpedToParentDK();
+    modifier notJumped(uint256 _coreDisputeID) {
+        if (!coreDisputeIDToActive[_coreDisputeID]) revert NotActiveForCoreDisputeID();
+        uint256 local = coreDisputeIDToLocal[_coreDisputeID];
+        if (disputes[local].jumped) revert DisputeJumpedToParentDK();
         _;
     }
🧹 Nitpick comments (4)
contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol (3)

209-221: Recurring DK reuse path looks good; consider idempotent active-flag set

Reusing the existing localDisputeID and resetting jumped=false is correct for returning disputes. Optionally reassert coreDisputeIDToActive[_coreDisputeID] = true; in both branches to be explicit/idempotent.

         if (coreDisputeIDToActive[_coreDisputeID]) {
             localDisputeID = coreDisputeIDToLocal[_coreDisputeID];
             dispute = disputes[localDisputeID];
             dispute.jumped = false;
+            coreDisputeIDToActive[_coreDisputeID] = true; // idempotent
         } else {
             localDisputeID = disputes.length;
             dispute = disputes.push();
             coreDisputeIDToLocal[_coreDisputeID] = localDisputeID;
             coreDisputeIDToActive[_coreDisputeID] = true;
         }

229-231: Guard against accidental underflow and cache the round ID

Minor: cache the core round ID and assert it exists before subtracting. Improves readability and prevents a panic if call ordering ever changes.

-        // New round in the Core should be created before the dispute creation in DK.
-        dispute.coreRoundIDToLocal[core.getNumberOfRounds(_coreDisputeID) - 1] = dispute.rounds.length;
+        // New round in the Core should be created before the dispute creation in DK.
+        uint256 coreRoundID = core.getNumberOfRounds(_coreDisputeID);
+        require(coreRoundID > 0, "Core round not initialized");
+        dispute.coreRoundIDToLocal[coreRoundID - 1] = dispute.rounds.length; // 1-based storage

462-463: Avoid -1 underflow panics; centralize local round lookup

All these sites assume coreRoundIDToLocal[_coreRoundID] != 0 and do - 1. If unset, you get a panic (arithmetic or OOB). Tests currently rely on panics to detect “wrong DK/round”, but a targeted error improves debuggability and future flexibility.

Suggestion:

  • Add an internal helper returning the 0-based localRound index with a descriptive revert.
  • Use it across read paths to remove duplication and accidental off-by-one errors.
// Add once:
error RoundNotHandledByThisDK();
function _localRoundIndexOrRevert(Dispute storage d, uint256 _coreRoundID) internal view returns (uint256) {
    uint256 idx1 = d.coreRoundIDToLocal[_coreRoundID];
    if (idx1 == 0) revert RoundNotHandledByThisDK(); // not mapped in this DK
    return idx1 - 1; // convert to 0-based
}

Then replace patterns like:

Round storage round = dispute.rounds[dispute.coreRoundIDToLocal[_coreRoundID] - 1];
Vote storage vote = dispute.rounds[dispute.coreRoundIDToLocal[_coreRoundID] - 1].votes[_voteID];
localRoundID = disputes[localDisputeID].coreRoundIDToLocal[_coreRoundID] - 1;

with:

uint256 lr = _localRoundIndexOrRevert(dispute, _coreRoundID);
Round storage round = dispute.rounds[lr];
Vote storage vote = dispute.rounds[lr].votes[_voteID];
localRoundID = _localRoundIndexOrRevert(disputes[localDisputeID], _coreRoundID);

Note: Keeping current tests is possible by also testing for RoundNotHandledByThisDK() in addition to panics.

Also applies to: 566-567, 579-580, 647-648, 670-671, 698-699, 708-709

contracts/test/foundry/KlerosCore_Appeals.t.sol (1)

471-800: Strong E2E coverage for recurring DK; consider reducing brittleness and duplication

  • Reliance on stdError.arithmeticError is brittle; a future switch to explicit custom errors would break tests. Consider accepting both via try/catch or dedicated helpers.
  • Extract small helpers for repeated steps (stake/draw/vote/fund/expectJump) to cut boilerplate and improve readability.

If you want, I can propose a small set of reusable helpers for these flows.

Please confirm if we should keep asserting for panics vs. migrate to explicit error types in DK.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3d197d6 and 1e7d185.

📒 Files selected for processing (2)
  • contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol (10 hunks)
  • contracts/test/foundry/KlerosCore_Appeals.t.sol (2 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Redirect rules - kleros-v2-neo
  • GitHub Check: Redirect rules - kleros-v2-testnet-devtools
  • GitHub Check: Header rules - kleros-v2-neo
  • GitHub Check: Header rules - kleros-v2-testnet-devtools
  • GitHub Check: Pages changed - kleros-v2-neo
  • GitHub Check: Pages changed - kleros-v2-testnet-devtools
  • GitHub Check: Redirect rules - kleros-v2-testnet
  • GitHub Check: Header rules - kleros-v2-testnet
  • GitHub Check: Redirect rules - kleros-v2-testnet
  • GitHub Check: Header rules - kleros-v2-testnet
  • GitHub Check: Pages changed - kleros-v2-testnet
  • GitHub Check: Pages changed - kleros-v2-testnet
  • GitHub Check: hardhat-tests
  • GitHub Check: Analyze (javascript)
🔇 Additional comments (2)
contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol (2)

28-28: Clarify 1-based index semantics in mapping

Storing the length (1-based) is fine and matches the new read-path offsets. Please keep this invariant documented prominently, as misuse will cause subtle -1 underflows elsewhere.


432-436: Indexing for the next round on no-jump path is correct

Setting coreRoundIDToLocal[coreRoundID + 1] = dispute.rounds.length + 1 before pushing the new round matches the 1-based scheme. Good.

coderabbitai[bot]
coderabbitai bot previously approved these changes Oct 12, 2025
Copy link

@jaybuidl jaybuidl changed the title fix(DK): allow recurring DK Complex DK jump sequences bug fix and extended testing Oct 14, 2025
@jaybuidl jaybuidl merged commit a934ba5 into dev Oct 14, 2025
15 of 20 checks passed
@jaybuidl jaybuidl deleted the feat/recurring-dk-test branch October 14, 2025 14:44
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol (1)

105-118: Reorder to check activity/ownership before touching local round; avoid underflow and wasted writes.

If the dispute is not active in this DK, dispute.rounds.length - 1 can underflow or you perform writes that will be reverted anyway. Call _castCommit first (it enforces active+ownership), then compute localRoundID and write recoveryCommitments.

-    ) external {
-        if (_recoveryCommit == bytes32(0)) revert EmptyRecoveryCommit();
-
-        uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID];
-        Dispute storage dispute = disputes[localDisputeID];
-        uint256 localRoundID = dispute.rounds.length - 1;
-        for (uint256 i = 0; i < _voteIDs.length; i++) {
-            recoveryCommitments[localDisputeID][localRoundID][_voteIDs[i]] = _recoveryCommit;
-        }
-
-        // `_castCommit()` ensures that the caller owns the vote and that dispute is active
-        _castCommit(_coreDisputeID, _voteIDs, _commit);
-        emit CommitCastShutter(_coreDisputeID, msg.sender, _commit, _recoveryCommit, _identity, _encryptedVote);
-    }
+    ) external {
+        if (_recoveryCommit == bytes32(0)) revert EmptyRecoveryCommit();
+        // `_castCommit()` ensures the caller owns the votes and the dispute is active in this DK.
+        _castCommit(_coreDisputeID, _voteIDs, _commit);
+        // Safe to touch local dispute/round after validation.
+        uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID];
+        Dispute storage dispute = disputes[localDisputeID];
+        uint256 localRoundID = dispute.rounds.length - 1;
+        for (uint256 i = 0; i < _voteIDs.length; i++) {
+            recoveryCommitments[localDisputeID][localRoundID][_voteIDs[i]] = _recoveryCommit;
+        }
+        emit CommitCastShutter(_coreDisputeID, msg.sender, _commit, _recoveryCommit, _identity, _encryptedVote);
+    }
contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol (1)

121-134: Same reorder needed as in Shutter: validate via _castCommit before computing localRoundID/writing.

Prevents arithmetic underflow and redundant writes in reverted paths.

-    ) external {
-        if (_recoveryCommit == bytes32(0)) revert EmptyRecoveryCommit();
-
-        uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID];
-        Dispute storage dispute = disputes[localDisputeID];
-        uint256 localRoundID = dispute.rounds.length - 1;
-        for (uint256 i = 0; i < _voteIDs.length; i++) {
-            recoveryCommitments[localDisputeID][localRoundID][_voteIDs[i]] = _recoveryCommit;
-        }
-
-        // `_castCommit()` ensures that the caller owns the vote and that dispute is active
-        _castCommit(_coreDisputeID, _voteIDs, _commit);
-        emit CommitCastShutter(_coreDisputeID, msg.sender, _commit, _recoveryCommit, _identity, _encryptedVote);
-    }
+    ) external {
+        if (_recoveryCommit == bytes32(0)) revert EmptyRecoveryCommit();
+        // Validate ownership + active state first.
+        _castCommit(_coreDisputeID, _voteIDs, _commit);
+        // Then write per-round recovery commitments.
+        uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID];
+        Dispute storage dispute = disputes[localDisputeID];
+        uint256 localRoundID = dispute.rounds.length - 1;
+        for (uint256 i = 0; i < _voteIDs.length; i++) {
+            recoveryCommitments[localDisputeID][localRoundID][_voteIDs[i]] = _recoveryCommit;
+        }
+        emit CommitCastShutter(_coreDisputeID, msg.sender, _commit, _recoveryCommit, _identity, _encryptedVote);
+    }
contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol (1)

56-75: Upgrade risk: changing mapping value type breaks live gating semantics

Switching coreDisputeIDToActive from mapping(uint256=>bool) to mapping(uint256=>Active) preserves the first field (dispute) but initializes currentRound to false for all pre-existing disputes. After upgrade, isActive will revert (DisputeJumpedToAnotherDisputeKit) for ongoing rounds until a fresh createDispute call sets currentRound=true, potentially bricking live disputes.

Options:

  • Transitional fallback in isActive: treat currentRound==false as true if dispute==true and the dispute’s latest local round maps to core’s current round.
  • Add a one-time post-upgrade init function (guarded, non-reentrant) to set currentRound=true for currently active disputes (requires iterability or tracked keys).
  • Maintain the old boolean mapping (e.g., coreDisputeIDToActiveLegacy) and default currentRound = legacy value on first access/mutation.

Minimal code change alternative (fallback in isActive):

-    modifier isActive(uint256 _coreDisputeID) {
-        if (!coreDisputeIDToActive[_coreDisputeID].dispute) revert DisputeUnknownInThisDisputeKit();
-        if (!coreDisputeIDToActive[_coreDisputeID].currentRound) revert DisputeJumpedToAnotherDisputeKit();
+    modifier isActive(uint256 _coreDisputeID) {
+        Active storage a = coreDisputeIDToActive[_coreDisputeID];
+        if (!a.dispute) revert DisputeUnknownInThisDisputeKit();
+        if (!a.currentRound) {
+            // Fallback for pre-upgrade disputes: consider active if local mapping matches core's current round.
+            uint256 localDisputeID = coreDisputeIDToLocal[_coreDisputeID];
+            if (disputes.length == 0) revert DisputeJumpedToAnotherDisputeKit();
+            uint256 coreRoundID = core.getNumberOfRounds(_coreDisputeID) - 1;
+            bool matches = (disputes[localDisputeID].coreRoundIDToLocal[coreRoundID] == (disputes[localDisputeID].rounds.length == 0 ? type(uint256).max : disputes[localDisputeID].rounds.length - 1));
+            if (!matches) revert DisputeJumpedToAnotherDisputeKit();
+            a.currentRound = true; // self-heal
+        }
         _;
     }

Document the migration in release notes. Based on learnings

🧹 Nitpick comments (9)
contracts/src/arbitration/interfaces/IDisputeKit.sol (1)

33-45: Clarify the precondition and call ordering for createDispute.

Good note. Recommend tightening the NatSpec to state the ordering and expected behavior on violation.

     /// @notice Creates a local dispute and maps it to the dispute ID in the Core contract.
     /// @dev Access restricted to Kleros Core only.
-    /// @dev The new `KlerosCore.Round` must be created before calling this function.
+    /// @dev Requirements:
+    /// - KlerosCore MUST have created the next Round for `_coreDisputeID` before calling this function.
+    /// - Implementations SHOULD revert if the round does not exist or the dispute is not active in this DK.
+    /// - Implementations SHOULD reuse an existing local dispute if already active (recurring DK scenarios).
     /// @param _coreDisputeID The ID of the dispute in Kleros Core, not in the Dispute Kit.
contracts/test/foundry/KlerosCore_Disputes.t.sol (1)

95-98: Minor: use disputeID variable and clearer tuple names.

Improves readability and avoids hardcoded IDs.

-        (bool dispute, bool currentRound) = disputeKit.coreDisputeIDToActive(0);
-        assertEq(dispute, true, "Dispute should be active in this DK");
-        assertEq(currentRound, true, "Current round should be active in this DK");
+        (bool disputeActive, bool currentRoundActive) = disputeKit.coreDisputeIDToActive(disputeID);
+        assertEq(disputeActive, true, "Dispute should be active in this DK");
+        assertEq(currentRoundActive, true, "Current round should be active in this DK");
contracts/test/foundry/KlerosCore_Execution.t.sol (1)

724-724: LGTM: withdrawal API and event updates reflected. Add idempotency check (optional).

The calls and event args match the simplified signature. Optionally, assert a second withdrawal reverts or returns 0 to cover idempotency.

Example addition after the successful withdraw:

vm.expectRevert(DisputeKitClassicBase.NothingToWithdraw.selector);
disputeKit.withdrawFeesAndRewards(disputeID, payable(crowdfunder1), 1);

Also applies to: 732-732, 740-746

contracts/test/foundry/KlerosCore_Appeals.t.sol (2)

301-317: Robustness: also assert Active.dispute when checking currentRound

You only assert currentRound; add a quick check for disputeActive to ensure the DK recognizes the core dispute at both old/new DKs.

-        (, bool currentRound) = newDisputeKit.coreDisputeIDToActive(disputeID);
-        assertEq(currentRound, false, "round should be jumped");
+        (bool disputeActive, bool currentRound) = newDisputeKit.coreDisputeIDToActive(disputeID);
+        assertEq(disputeActive, true, "dispute should exist in the previous DK");
+        assertEq(currentRound, false, "round should be jumped");

439-455: Same nit: assert both fields of Active

Mirrors the previous suggestion for consistency and clearer failures.

-        (, bool currentRound) = disputeKit3.coreDisputeIDToActive(disputeID);
-        assertEq(currentRound, false, "round should be jumped");
+        (bool disputeActive, bool currentRound) = disputeKit3.coreDisputeIDToActive(disputeID);
+        assertEq(disputeActive, true, "dispute should exist in the previous DK");
+        assertEq(currentRound, false, "round should be jumped");
-        (, currentRound) = disputeKit2.coreDisputeIDToActive(disputeID);
-        assertEq(currentRound, true, "round should be active in the DK that dispute jumped to");
+        (disputeActive, currentRound) = disputeKit2.coreDisputeIDToActive(disputeID);
+        assertEq(disputeActive, true, "dispute should exist in the new DK");
+        assertEq(currentRound, true, "round should be active in the DK that dispute jumped to");
contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol (4)

111-117: Event signature change: confirm downstream consumers

Withdrawal now omits coreRoundID. Ensure off-chain indexers/UI consuming the old 4-topic signature are updated, or emit a deprecated event for one release to ease transition.

Would you like a thin compatibility event emitted alongside the new one for a deprecation window?


138-142: Modifier name vs semantics

isActive() now encodes both “known dispute” and “current round on this DK.” Clear. Consider renaming to requireCurrentRound() for readability, keeping isActive as a pure “known” check for public views.


372-436: Appeal/jump flow is correct; minor gas and clarity tweaks

  • External call this.currentRuling is more expensive than reading state; if not required for polymorphism, prefer internal helper.
  • When marking a jump, only currentRound is flipped; dispute remains true—good.
-        (uint256 ruling, , ) = this.currentRuling(_coreDisputeID);
+        (uint256 ruling, , ) = currentRuling(_coreDisputeID);

Make currentRuling internal/virtual (and add a public wrapper) if polymorphism is needed.


588-606: View functions can underflow on unknown disputes

getFundedChoices/areVotesAllCast and similar read disputes[coreDisputeIDToLocal[_coreDisputeID]] and access rounds[length-1]. If the dispute is unknown to this DK, length==0 underflows. Either:

  • Add a lightweight known-dispute check (a.dispute) and return safe defaults, or
  • Document that callers must use coreDisputeIDToActive first.
function getFundedChoices(uint256 _coreDisputeID) public view returns (uint256[] memory fundedChoices) {
-    Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]];
+    if (!coreDisputeIDToActive[_coreDisputeID].dispute) revert DisputeUnknownInThisDisputeKit();
+    Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]];
    ...
}

Apply similarly to other views that index the last round. As per coding guidelines

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1e7d185 and a0a3a67.

📒 Files selected for processing (8)
  • contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol (11 hunks)
  • contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol (2 hunks)
  • contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol (2 hunks)
  • contracts/src/arbitration/interfaces/IDisputeKit.sol (1 hunks)
  • contracts/test/foundry/KlerosCore_Appeals.t.sol (6 hunks)
  • contracts/test/foundry/KlerosCore_Disputes.t.sol (1 hunks)
  • contracts/test/foundry/KlerosCore_Execution.t.sol (2 hunks)
  • contracts/test/foundry/KlerosCore_Voting.t.sol (2 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-30T17:18:12.895Z
Learnt from: jaybuidl
PR: kleros/kleros-v2#2145
File: contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol:277-286
Timestamp: 2025-09-30T17:18:12.895Z
Learning: In DisputeKitClassicBase.sol's castCommit function, jurors are allowed to re-submit commits during the commit period. The implementation uses a commitCount variable to track only first-time commits (where commit == bytes32(0)) so that totalCommitted is not incremented when a juror updates their existing commit.

Applied to files:

  • contracts/src/arbitration/dispute-kits/DisputeKitShutter.sol
  • contracts/src/arbitration/dispute-kits/DisputeKitGatedShutter.sol
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Redirect rules - kleros-v2-testnet
  • GitHub Check: Redirect rules - kleros-v2-testnet
  • GitHub Check: Header rules - kleros-v2-testnet
  • GitHub Check: Header rules - kleros-v2-testnet
  • GitHub Check: Pages changed - kleros-v2-testnet
  • GitHub Check: Pages changed - kleros-v2-testnet
  • GitHub Check: Redirect rules - kleros-v2-testnet-devtools
  • GitHub Check: Redirect rules - kleros-v2-neo
  • GitHub Check: Header rules - kleros-v2-testnet-devtools
  • GitHub Check: Header rules - kleros-v2-neo
  • GitHub Check: Pages changed - kleros-v2-testnet-devtools
  • GitHub Check: Pages changed - kleros-v2-neo
  • GitHub Check: hardhat-tests
  • GitHub Check: Analyze (javascript)
🔇 Additional comments (8)
contracts/test/foundry/KlerosCore_Disputes.t.sol (1)

91-94: LGTM: updated disputes() return shape.

Tuple now matches (numberOfChoices, extraData). Assertions are correct.

contracts/test/foundry/KlerosCore_Voting.t.sol (2)

459-467: API alignment looks good

Switching to destructuring Active and the updated disputes() return shape matches the new DK interface. No issues spotted.


477-477: Correct revert selector update

Using DisputeUnknownInThisDisputeKit is consistent with the new isActive/unknown semantics.

contracts/test/foundry/KlerosCore_Appeals.t.sol (2)

470-775: End-to-end recurring DK test is thorough

Great coverage across DK2↔DK3 jumps, local/core IDs, funded choices, periods, draws, voting, and rewards. Event expectations align with the new flow and updated Withdrawal signature.


826-827: Updated disputes() tuple desugaring LGTM

The reduced return arity is correctly handled here.

contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol (3)

200-231: createDispute: safe round index and id reuse look good

  • Reusing localDisputeID on re-entry across recurring DKs is correct.
  • Mapping coreRoundIDToLocal to rounds.length pre-push is correct (points to the new round index).

Please confirm core calls createDispute after creating the new core round; otherwise core.getNumberOfRounds(_coreDisputeID) - 1 underflows.


631-636: getJumpDisputeKitID fallback is sensible

Returning classic when jumpDisputeKitID is unset matches expectations for graceful degradation.


755-759: Error renaming is coherent with semantics

DisputeJumpedToAnotherDisputeKit and DisputeUnknownInThisDisputeKit clarify causes. Ensure tests and docs updated (they seem to be).

Comment on lines 438 to 485
/// @notice Allows those contributors who attempted to fund an appeal round to withdraw any reimbursable fees or rewards after the dispute gets resolved.
/// @dev Withdrawals are not possible if the core contract is paused.
/// @dev It can be called after the dispute has jumped to another dispute kit.
/// @param _coreDisputeID Index of the dispute in Kleros Core contract.
/// @param _beneficiary The address whose rewards to withdraw.
/// @param _coreRoundID The round in the Kleros Core contract the caller wants to withdraw from.
/// @param _choice The ruling option that the caller wants to withdraw from.
/// @return amount The withdrawn amount.
function withdrawFeesAndRewards(
uint256 _coreDisputeID,
address payable _beneficiary,
uint256 _coreRoundID,
uint256 _choice
) external returns (uint256 amount) {
(, , , bool isRuled, ) = core.disputes(_coreDisputeID);
if (!isRuled) revert DisputeNotResolved();
if (core.paused()) revert CoreIsPaused();
if (!coreDisputeIDToActive[_coreDisputeID]) revert NotActiveForCoreDisputeID();
if (!coreDisputeIDToActive[_coreDisputeID].dispute) revert DisputeUnknownInThisDisputeKit();

Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]];
Round storage round = dispute.rounds[dispute.coreRoundIDToLocal[_coreRoundID]];
(uint256 finalRuling, , ) = core.currentRuling(_coreDisputeID);

if (!round.hasPaid[_choice]) {
// Allow to reimburse if funding was unsuccessful for this ruling option.
amount = round.contributions[_beneficiary][_choice];
} else {
// Funding was successful for this ruling option.
if (_choice == finalRuling) {
// This ruling option is the ultimate winner.
amount = round.paidFees[_choice] > 0
? (round.contributions[_beneficiary][_choice] * round.feeRewards) / round.paidFees[_choice]
: 0;
} else if (!round.hasPaid[finalRuling]) {
// The ultimate winner was not funded in this round. In this case funded ruling option(s) are reimbursed.
amount =
(round.contributions[_beneficiary][_choice] * round.feeRewards) /
(round.paidFees[round.fundedChoices[0]] + round.paidFees[round.fundedChoices[1]]);
for (uint256 i = 0; i < dispute.rounds.length; i++) {
Round storage round = dispute.rounds[i];

if (!round.hasPaid[_choice]) {
// Allow to reimburse if funding was unsuccessful for this ruling option.
amount += round.contributions[_beneficiary][_choice];
} else {
// Funding was successful for this ruling option.
if (_choice == finalRuling) {
// This ruling option is the ultimate winner.
amount += round.paidFees[_choice] > 0
? (round.contributions[_beneficiary][_choice] * round.feeRewards) / round.paidFees[_choice]
: 0;
} else if (!round.hasPaid[finalRuling]) {
// The ultimate winner was not funded in this round. In this case funded ruling option(s) are reimbursed.
amount +=
(round.contributions[_beneficiary][_choice] * round.feeRewards) /
(round.paidFees[round.fundedChoices[0]] + round.paidFees[round.fundedChoices[1]]);
}
}
round.contributions[_beneficiary][_choice] = 0;
}
round.contributions[_beneficiary][_choice] = 0;

if (amount != 0) {
_beneficiary.safeSend(amount, wNative);
emit Withdrawal(_coreDisputeID, _coreRoundID, _choice, _beneficiary, amount);
emit Withdrawal(_coreDisputeID, _choice, _beneficiary, amount);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Withdrawals: guard unknown disputes and handle edge cases explicitly

  • Good: allows withdrawals after jumps; iterates all rounds; zeroes contributions.
  • Edge case: else-if branch assumes two funded choices; enforce fundedChoices.length == 2 to avoid accidental OOB or skew if rules ever change.
-                } else if (!round.hasPaid[finalRuling]) {
+                } else if (!round.hasPaid[finalRuling]) {
+                    // Expect exactly 2 funded choices in this design.
+                    if (round.fundedChoices.length < 2) continue;
                     amount +=
                         (round.contributions[_beneficiary][_choice] * round.feeRewards) /
                         (round.paidFees[round.fundedChoices[0]] + round.paidFees[round.fundedChoices[1]]);
                 }

Also consider reverting with DisputeInconsistentFunding() if length != 2 to surface data inconsistencies early.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol around lines
438 to 485, the else-if branch assumes round.fundedChoices has exactly two
entries when summing paidFees by indexing [0] and [1]; add an explicit guard
that round.fundedChoices.length == 2 before accessing those indices and if not
true revert with DisputeInconsistentFunding() (or an existing appropriate error)
to surface inconsistent funding state; place this check immediately before the
division that uses fundedChoices and use the guarded branch to compute the
reimbursement, otherwise revert.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Contracts: Possible bug when a dispute jumps back to a previously used Dispute Kit

2 participants